Выпускной проект Яндекс Практикума. Часть II
Цель: оценить, повлияло ли внедрение улучшенной рекомендательной системы на метрики продаж.
Данные:
Заказчик: отдел маркетинга.
Задачи:
import math as mth
import numpy as np
import pandas as pd
import seaborn as sns
import datetime as dt
import scipy.stats as stats
import plotly.express as px
from datetime import datetime
from matplotlib import pyplot as plt
from plotly import graph_objects as go
pd.set_option('mode.chained_assignment', None)
Откроем датасеты и сохраним данные в переменных.
events, marketing, users, tests = (
pd.read_csv('final_ab_events.csv'),
pd.read_csv('ab_project_marketing_events.csv'),
pd.read_csv('final_ab_new_users.csv'),
pd.read_csv('final_ab_participants.csv')
)
display(events.head())
display(marketing.head())
display(users.head())
tests.head()
| user_id | event_dt | event_name | details | |
|---|---|---|---|---|
| 0 | E1BDDCE0DAFA2679 | 2020-12-07 20:22:03 | purchase | 99.99 |
| 1 | 7B6452F081F49504 | 2020-12-07 09:22:53 | purchase | 9.99 |
| 2 | 9CD9F34546DF254C | 2020-12-07 12:59:29 | purchase | 4.99 |
| 3 | 96F27A054B191457 | 2020-12-07 04:02:40 | purchase | 4.99 |
| 4 | 1FD7660FDF94CA1F | 2020-12-07 10:15:09 | purchase | 4.99 |
| name | regions | start_dt | finish_dt | |
|---|---|---|---|---|
| 0 | Christmas&New Year Promo | EU, N.America | 2020-12-25 | 2021-01-03 |
| 1 | St. Valentine's Day Giveaway | EU, CIS, APAC, N.America | 2020-02-14 | 2020-02-16 |
| 2 | St. Patric's Day Promo | EU, N.America | 2020-03-17 | 2020-03-19 |
| 3 | Easter Promo | EU, CIS, APAC, N.America | 2020-04-12 | 2020-04-19 |
| 4 | 4th of July Promo | N.America | 2020-07-04 | 2020-07-11 |
| user_id | first_date | region | device | |
|---|---|---|---|---|
| 0 | D72A72121175D8BE | 2020-12-07 | EU | PC |
| 1 | F1C668619DFE6E65 | 2020-12-07 | N.America | Android |
| 2 | 2E1BF1D4C37EA01F | 2020-12-07 | EU | PC |
| 3 | 50734A22C0C63768 | 2020-12-07 | EU | iPhone |
| 4 | E1BDDCE0DAFA2679 | 2020-12-07 | N.America | iPhone |
| user_id | group | ab_test | |
|---|---|---|---|
| 0 | D1ABA3E2887B6A73 | A | recommender_system_test |
| 1 | A7A3664BD6242119 | A | recommender_system_test |
| 2 | DABC14FDDFADD29E | A | recommender_system_test |
| 3 | 04988C5DF189632E | A | recommender_system_test |
| 4 | 482F14783456D21B | B | recommender_system_test |
Выведем информацию о датасетах, проверим типы данных в столбцах.
display(events.info())
display(marketing.info())
display(users.info())
tests.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 440317 entries, 0 to 440316 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 440317 non-null object 1 event_dt 440317 non-null object 2 event_name 440317 non-null object 3 details 62740 non-null float64 dtypes: float64(1), object(3) memory usage: 13.4+ MB
None
<class 'pandas.core.frame.DataFrame'> RangeIndex: 14 entries, 0 to 13 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 name 14 non-null object 1 regions 14 non-null object 2 start_dt 14 non-null object 3 finish_dt 14 non-null object dtypes: object(4) memory usage: 576.0+ bytes
None
<class 'pandas.core.frame.DataFrame'> RangeIndex: 61733 entries, 0 to 61732 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 61733 non-null object 1 first_date 61733 non-null object 2 region 61733 non-null object 3 device 61733 non-null object dtypes: object(4) memory usage: 1.9+ MB
None
<class 'pandas.core.frame.DataFrame'> RangeIndex: 18268 entries, 0 to 18267 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 18268 non-null object 1 group 18268 non-null object 2 ab_test 18268 non-null object dtypes: object(3) memory usage: 428.3+ KB
Приведем даты в таблице events к datetime и создадим столбец с датой.
events['event_dt'] = pd.to_datetime(events['event_dt'])
events['date'] = events['event_dt'].dt.date
users['first_date'] = pd.to_datetime(users['first_date']).dt.date
marketing['start_dt'] = pd.to_datetime(marketing['start_dt']).dt.date
marketing['finish_dt'] = pd.to_datetime(marketing['finish_dt']).dt.date
Выведем данные о пропусках.
display(events.isna().sum())
display(marketing.isna().sum())
display(users.isna().sum())
tests.isna().sum()
user_id 0 event_dt 0 event_name 0 details 377577 date 0 dtype: int64
name 0 regions 0 start_dt 0 finish_dt 0 dtype: int64
user_id 0 first_date 0 region 0 device 0 dtype: int64
user_id 0 group 0 ab_test 0 dtype: int64
Пропуски только в одном столбце details — рассмотрим подробнее.
print('Доля пропусков в столбце «details»:', round(events['details'].isna().sum() / events['user_id'].count(), 2))
Доля пропусков в столбце «details»: 0.86
Пропуски столбца составляют 86%. Смотрим, привязаны ли имеющиеся данные к какому-то значению столбца event_name.
(
events
.groupby('event_name')
.agg(count=('user_id', 'count'))
.merge(events[~(events['details'].isna())]
.groupby('event_name')
.agg(details=('user_id', 'count')), how='outer', on='event_name')
)
| count | details | |
|---|---|---|
| event_name | ||
| login | 189552 | NaN |
| product_cart | 62462 | NaN |
| product_page | 125563 | NaN |
| purchase | 62740 | 62740.0 |
Видим, что все значения столбца относятся только к покупкам и не связаны с другими действиями. Таким образом, подтверждается информация ТЗ: в столбце находятся дополнительные данные о событии, например, для purchase — стоимость покупки. Выведем значения столбца.
events['details'].value_counts()
4.99 46362 9.99 9530 99.99 5631 499.99 1217 Name: details, dtype: int64
Принимаем решения оставить столбец как есть, эти данные пригодятся в дальнейшем для анализа равномерности групп.
Проверим датасеты на явные дубликаты.
display(events.duplicated().sum())
display(marketing.duplicated().sum())
display(users.duplicated().sum())
tests.duplicated().sum()
0
0
0
0
Проверим датасеты на дублирующихся пользователей.
display(users['user_id'].duplicated().sum())
tests['user_id'].duplicated().sum()
0
1602
В данных о новых пользователях все пользователи уникальны. В датасете об участниках тестов есть дублирующиеся пользователи. Учтем этот факт на следующем этапе.
events создан дополнительный столбец с датой. Создадим переменную с участниками только нашего теста.
data = tests[tests['ab_test'] == 'recommender_system_test']
print('Количество пользователей теста:', data['user_id'].count())
Количество пользователей теста: 6701
Посмотрим на минимальную и максимальную даты в данных о новых пользователях.
print('Начало:', users['first_date'].min())
print('Окончание:', users['first_date'].max())
Начало: 2020-12-07 Окончание: 2020-12-23
Есть лишние данные о пользователях за 22 и 23 декабря, будем это учитывать. Зададим переменные с датами старта и окончания набора в тест, а также с датой окончания самого теста.
start = users['first_date'].min()
finish = start + dt.timedelta(days=14)
end = finish + dt.timedelta(days=14)
Присоединим данные из датасета с новыми пользователями и заодно проверим, все ли пользователи нашего теста зарегистрировались в нужный промежуток времени.
data = (
data
.merge(
users
.query('@start <= first_date <= @finish'), how='inner'
)
)
data.rename(columns={'first_date': 'reg_date'}, inplace = True)
data['user_id'].count()
6701
Все пользователи зарегистрировались в нужный период.
Проверим даты датасета с событиями.
print('Начало:', events['date'].min())
print('Окончание:', events['date'].max())
Начало: 2020-12-07 Окончание: 2020-12-30
Дата старта записи событий соответствует началу теста. Дата окончания записи событий не соответствует ТЗ, не хватает нескольких дней до 4 января. Это означает, что часть пользователей не прожила необходимые 14 дней. Оценим количество пользователей, которые не прожили полный цикл.
last_full = events['date'].max() - dt.timedelta(days=14)
print('Количество пользователей, не проживших 14 дней:', data[data['reg_date'] > last_full]['user_id'].count())
print('Доля пользователей, не проживших 14 дней:',
round(data[data['reg_date'] > last_full]['user_id'].count() /
data['user_id'].count(), 2))
Количество пользователей, не проживших 14 дней: 2387 Доля пользователей, не проживших 14 дней: 0.36
Доля таких пользователей очень большая. Пока оставим все как есть, учтем при анализе лайфтайма.
Проверим информацию о регионе пользователей.
data['region'].value_counts()
EU 6351 N.America 223 APAC 72 CIS 55 Name: region, dtype: int64
not_EU = data.query('not region == "EU"')['user_id'].count()
print('Количество пользователей не из Европы, отобранных в тест:', not_EU)
print('Доля в датасете:', round(not_EU / data['user_id'].count(), 2))
Количество пользователей не из Европы, отобранных в тест: 350 Доля в датасете: 0.05
Часть пользователей не подходит по региону. Оставляем только пользователей из Европы и проверяем, составляют ли они 15% от всех новых пользователей из Европы.
data = data[data['region'] == 'EU']
data['user_id'].count() / (
users
.query('region == "EU" and @start <= first_date <= @finish')['user_id'].count()
)
0.15
По этому критерию данные пока соответствуют.
Выведем количество наших пользователей и дубликаты.
print('Количество пользователей:', data['user_id'].count())
print('Дубликатов внутри теста:', data['user_id'].duplicated().sum())
Количество пользователей: 6351 Дубликатов внутри теста: 0
Внутри нужного теста дублирующихся пользователей нет, значит все дубликаты, обнаруженные на этапе обзора данных, это пересечения пользователей между двумя тестами. Оценим их распределение по группам прежде всего конкурирующего теста.
(
tests[tests['user_id'].duplicated(keep=False)]
.groupby(['ab_test', 'group'])
.agg(count=('user_id', 'count'))
)
| count | ||
|---|---|---|
| ab_test | group | |
| interface_eu_test | A | 819 |
| B | 783 | |
| recommender_system_test | A | 921 |
| B | 681 |
На пользователей, попавших в группу А конкурирующего теста, никакого воздействия не оказывалось, поэтому их можно не отсеивать. Посмотрим, какую долю наших пользователей составляют пользователи группы Б конкурирующего теста.
other_B = (
tests[
(tests['user_id'].duplicated(keep=False)) &
(tests['ab_test'] == 'interface_eu_test') &
(tests['group'] == 'B')]
)
print(round(other_B['user_id'].count() / data['user_id'].count(), 2))
0.12
12% это довольно много: при удалении пользователей станет заметно меньше 6000. Посмотрим, как они распределились по группам нашего теста.
other_B_in_data = (
data[data['user_id'].isin(other_B['user_id'])]
.groupby('group')
.agg(dupl=('user_id', 'count'))
.merge(
data
.groupby('group')
.agg(count=('user_id', 'count')), on='group'
)
)
other_B_in_data['share'] = other_B_in_data['dupl'] / other_B_in_data['count']
other_B_in_data
| dupl | count | share | |
|---|---|---|---|
| group | |||
| A | 439 | 3634 | 0.120804 |
| B | 344 | 2717 | 0.126610 |
Распределение по группам нашего теста примерно одинаковое. Примем решение условно считать влияние другого теста на обе наши группы одинаковым и оставить дубликаты с конкурирующим тестом.
Добавим в датасет с пользователями информацию о событиях и рассчитаем лайфтайм.
data = (
data
.merge(events, how='left')
.drop(['ab_test', 'event_dt'], axis=1)
)
data['lifetime'] = data['date'] - data['reg_date']
data['lifetime'] = data['lifetime'].dt.days
data.rename(columns={'date': 'ev_date'}, inplace = True)
Посмотрим, на какой день обычно пользователи совершают события.
plt.figure(figsize=(7, 4))
sns.histplot(data['lifetime'], binwidth=1)
plt.title('Распределений событий по лайфтайму')
plt.ylabel('кол-во клиентов')
plt.xlabel('лайфтайм')
plt.show()
Мы видим, что большинство действий пользователи совершают в первую неделю лайфтайма. Посмотрим на перцентили.
perc_lt = np.nanpercentile(data['lifetime'], [90, 95, 99])
perc_lt
array([ 9., 12., 18.])
90% событий происходит в первые 9 дней лайфтайма, этот срок совпадает с наиболее коротким сроком жизни наших пользователей, набранных 21 декабря (0-й день) и доживших до 30 декабря (9-й день). Принимаем окончательное решение оставить пользователей, не проживших полный лайфтайм.
Отсекаем события, совершенные после 14 дня.
data = data[~(data['lifetime'] > 14)]
data['user_id'].nunique()
6351
Оставшееся после отсечения количество уникальных пользователей не изменилось, то есть среди наших пользователей не было таких, кто начал совершать события только после 14 дня.
Оценим количество пользователей, не совершавших никаких событий.
print('Количество пользователей, не совершавших событий:', data['event_name'].isna().sum())
print('Доля от общего числа:', round(data['event_name'].isna().sum() / data['user_id'].nunique(), 2))
Количество пользователей, не совершавших событий: 2870 Доля от общего числа: 0.45
Оценим возможное влияние маркетинговых событий на тест. Выведем информацию об акциях проходивших а) в Европе и б) полностью или частично в сроки нашего теста.
marketing.query('regions.str.contains("EU") and start_dt <= @end and finish_dt >= @start')
| name | regions | start_dt | finish_dt | |
|---|---|---|---|---|
| 0 | Christmas&New Year Promo | EU, N.America | 2020-12-25 | 2021-01-03 |
Есть одна акция, проходившая в Европе в дни теста. Необходимо оценить ее возможное влияние на пользователей. Строим график распределения событий по календарным дням.
plt.figure(figsize=(10, 4))
sns.histplot(
data.query('not ev_date.isna()')['ev_date'],
binwidth=1
)
plt.title('Распределений событий по календарным дням')
plt.ylabel('кол-во событий')
plt.xlabel('даты')
plt.show()
На графике не видим всплеска событий в дни проведения акции, т. е. с 25 декабря. Принимаем решение условно считать воздействие акции на пользователей незначительным.
Выведем распределение пользователей в тесте по группам.
groups = data.groupby('group', as_index=False).agg(size=('user_id', 'nunique'))
groups['share'] = round(groups['size'] / data['user_id'].nunique(), 2)
groups
| group | size | share | |
|---|---|---|---|
| 0 | A | 3634 | 0.57 |
| 1 | B | 2717 | 0.43 |
57% в группе А и 43% в группе Б — распределение не совсем равномерное.
Еще раз выведем данные на соответствие критериям.
print('Общее количество пользователей в тесте:', data['user_id'].nunique())
print('Дата начала регистрации:', data['reg_date'].min())
print('Дата окончания регистрации:', data['reg_date'].max())
print('Регион пользователей:', data['region'].value_counts())
print('Доля пользователей в тесте от всех новых пользователей из Европы:',
data['user_id'].nunique() / users.query('region == "EU" and @start <= first_date <= @finish')['user_id'].count()
)
print('Начало совершения событий:', data.query('not ev_date.isna()')['ev_date'].min())
print('Окончание совершения событий:', data.query('not ev_date.isna()')['ev_date'].max())
print(f'Пользователей в группе А:', data.query('group == "A"')['user_id'].nunique())
print('Пользователей в группе Б:', data.query('group == "B"')['user_id'].nunique())
Общее количество пользователей в тесте: 6351 Дата начала регистрации: 2020-12-07 Дата окончания регистрации: 2020-12-21 Регион пользователей: EU 25698 Name: region, dtype: int64 Доля пользователей в тесте от всех новых пользователей из Европы: 0.15 Начало совершения событий: 2020-12-07 Окончание совершения событий: 2020-12-29 Пользователей в группе А: 3634 Пользователей в группе Б: 2717
Обращаем внимание, что дата окончания совершения событий уменьшилась на 1 день — значит, все действия, совершенные пользователями 30 декабря, были совершены позже 14 дня лайфтайма.
Соответствие данных техническому заданию:
+ общее количество пользователей больше 6000,+ даты начала и окончания набора в тест соответствуют,+ аудитория — 15% пользователей из Европы,- окончание совершения событий за несколько дней до полного лайфтайма последней когорты,+/- распределение пользователей по группам не вполне равномерное,- по первоначальному отбору в тест попало 350 пользователей из других регионов — 5% датасета,- 45% пользователей в датасете не совершали никаких событий.Посмотрим, какое количество пользователей в каждой группе вообще не совершало событий.
groups = (
groups
.merge(
data
.query('event_name.isna()')
.groupby('group', as_index=False)
.agg(no_events=('user_id', 'nunique')), how='left'
)
)
groups['no_events_group_share'] = round(groups['no_events'] / groups['size'], 2)
groups['no_events_common_share'] = round(groups['no_events'] / data['user_id'].nunique(), 2)
groups
| group | size | share | no_events | no_events_group_share | no_events_common_share | |
|---|---|---|---|---|---|---|
| 0 | A | 3634 | 0.57 | 1030 | 0.28 | 0.16 |
| 1 | B | 2717 | 0.43 | 1840 | 0.68 | 0.29 |
Пользователей, не совершавших никаких действий, в группе Б намного больше: 68% от размера группы и 29% от всех пользователей. Выведем даты их регистраций.
fig, axes = plt.subplots(1, 2, figsize=(15, 5), sharey=True, tight_layout=True)
plt.suptitle('Даты регистраций пользователей без событий', fontsize=18)
group_fig = ['A', 'B']
for y in range(len(group_fig)):
sns.histplot(data=data[data['group'] == group_fig[y]].query('event_name.isna()'), x='reg_date', binwidth=1, ax=axes[y])
axes[y].title.set_text(f'группа {group_fig[y]}')
axes[y].tick_params(labelrotation=20)
В распределении по дням количество таких пользователей не отличается и для обеих групп не превышает 250 за 1 день. Разница в том, что в группе А эти пользователи появлялись только в первую неделю рассматриваемого периода, в группе Б — в течение всего периода.
Смотрим, каково соотношение пользователей без действий по группам.
dt = data.query('not event_name.isna()')
grps = dt.groupby('group', as_index=False).agg(size=('user_id', 'nunique'))
grps['share'] = round(grps['size'] / dt['user_id'].nunique(), 2)
print('Итоговое количество пользователей, совершавших события:', dt['user_id'].nunique())
grps
Итоговое количество пользователей, совершавших события: 3481
| group | size | share | |
|---|---|---|---|
| 0 | A | 2604 | 0.75 |
| 1 | B | 877 | 0.25 |
После удаления пользователей без событий распределение по группам стало еще более неравномерным.
Исследуем группы А и Б на однородность.
fig, axes = plt.subplots(3, 2, figsize=(15, 15), sharey='row', tight_layout=True)
column_fig = ['reg_date', 'ev_date', 'lifetime']
for i in range(len(column_fig)):
for y in range(len(group_fig)):
sns.histplot(data=dt[dt['group'] == group_fig[y]], x=column_fig[i], binwidth=1, ax=axes[i, y])
axes[i, y].title.set_text(f'{column_fig[i]}, группа {group_fig[y]}')
axes[i, y].title.set_size(18)
axes[i, y].tick_params(labelrotation=20)
В группе А значительный и устойчивый рост количества регистраций и количества событий на вторую неделю эксперимента (с 14 декабря). Пик количества событий 21 декабря, в последний день набора пользователей в тест.
У группы Б были лучше показатели по количеству событий в первую неделю эксперимента: количество событий в двух группах в этот период находится примерно на одном уровне, тогда как сама группа в 3 раза меньше.
Характер лайфтайма в обеих группах совпадает. Количество событий в первый день значительно превышает все остальные дни.
for item in ['device', 'event_name']:
data_fig = (
dt
.groupby([item, 'group'])
.agg(count=('user_id', 'count'))
.reset_index()
)
data_fig['percent'] = ''
total_A = dt.query('group == "A"')[item].count()
total_B = dt.query('group == "B"')[item].count()
for i in range(len(data_fig)):
if data_fig['group'][i] == 'A':
data_fig['percent'][i] = round(data_fig['count'][i] / total_A * 100, 2)
else:
data_fig['percent'][i] = round(data_fig['count'][i] / total_B * 100, 2)
fig = px.bar(data_fig,
x=item,
y='count',
color='group',
text='percent',
title=f'Относительное распределение {item} по группам, %'
)
fig.update_layout(barmode='stack', xaxis={'categoryorder':'total descending'})
fig.show()
device = (
dt
.groupby(['device', 'group'])
.agg(count=('user_id', 'count'))
.reset_index()
)
device['percent'] = ''
device_A = dt.query('group == "A"')['device'].count()
device_B = dt.query('group == "B"')['device'].count()
for i in range(len(device)):
if device['group'][i] == 'A':
device['percent'][i] = round(device['count'][i] / device_A * 100, 2)
else:
device['percent'][i] = round(device['count'][i] / device_B * 100, 2)
fig = px.bar(device,
x='device',
y='count',
color='group',
text='percent',
title='Относительное распределение числа заходов с разных устройств, %'
)
fig.update_layout(barmode='stack', xaxis={'categoryorder':'total descending'})
fig.show()
Выбор устройств внутри групп в целом похож, колебания не более 3%. Отметим, что пользователи внутри группы Б немного чаще заходят с мобильных устройств.
event = (
dt
.groupby(['event_name', 'group'])
.agg(count=('user_id', 'count'))
.reset_index()
)
event['percent'] = ''
event_A = dt.query('group == "A"')['event_name'].count()
event_B = dt.query('group == "B"')['event_name'].count()
for i in range(len(event)):
if event['group'][i] == 'A':
event['percent'][i] = round(event['count'][i] / event_A * 100, 2)
else:
event['percent'][i] = round(event['count'][i] / event_B * 100, 2)
fig = px.bar(event,
x='event_name',
y='count',
color='group',
text='percent',
title='Относительное распределение числа событий, %'
)
fig.update_layout(barmode='stack', xaxis={'categoryorder':'total descending'})
fig.show()
Относительное распределения типов событий внутри групп тоже похоже. Но отметим, что пользователи группы Б все же немного чаще логинятся, чем просматривают страницы продуктов или совершают покупки.
details = dt.pivot_table(index='details', columns='group', values='event_name', aggfunc='count')
details['A_%'] = round(details['A'] / dt.query('group == "A"')['details'].count() * 100, 2)
details['B_%'] = round(details['B'] / dt.query('group == "B"')['details'].count() * 100, 2)
details
| group | A | B | A_% | B_% |
|---|---|---|---|---|
| details | ||||
| 4.99 | 1858 | 469 | 74.35 | 75.16 |
| 9.99 | 374 | 93 | 14.97 | 14.90 |
| 99.99 | 222 | 50 | 8.88 | 8.01 |
| 499.99 | 45 | 12 | 1.80 | 1.92 |
Суммы покупок в группах различаючтся также незначительно. Чуть чаще (1%) пользователи группы Б совершают дешевые покупки (4.99$), чем на большую сумму.
Выведем среднее и медианное количество событий на пользователя по группам.
events = dt.groupby(['user_id', 'group']).agg(events=('event_name', 'count'))
events.groupby('group').agg(mean=('events', 'mean'), median=('events', 'median'))
| mean | median | |
|---|---|---|
| group | ||
| A | 6.903610 | 6.0 |
| B | 5.531357 | 4.0 |
И средний и медианный показатель в группе А лучше.
Посчитаем, сколько уникальных пользователей в каждой группе хоть раз совершали каждое из событий.
funnel = (
dt
.pivot_table(index='event_name', columns='group', values='user_id', aggfunc='nunique')
.sort_values(by='A', ascending=False)
.reset_index()
)
funnel
| group | event_name | A | B |
|---|---|---|---|
| 0 | login | 2604 | 876 |
| 1 | product_page | 1685 | 493 |
| 2 | purchase | 833 | 249 |
| 3 | product_cart | 782 | 244 |
Покупок у нас среди событий больше, чем просмотров корзины. (То есть, в магазине реализована функция быстрой покупки, позволяющая миновать этот этап). Зададим индексы для правильного отображения воронки и посчитаем конверсию на каждом этапе.
funnel = funnel.reindex([0, 1, 3, 2])
funnel['A_conv'] = funnel['A'] / funnel['A'][0]
funnel['B_conv'] = funnel['B'] / funnel['B'][0]
funnel['A_step'] = funnel['A'] / funnel['A'].shift(1)
funnel['B_step'] = funnel['B'] / funnel['B'].shift(1)
funnel
| group | event_name | A | B | A_conv | B_conv | A_step | B_step |
|---|---|---|---|---|---|---|---|
| 0 | login | 2604 | 876 | 1.000000 | 1.000000 | NaN | NaN |
| 1 | product_page | 1685 | 493 | 0.647081 | 0.562785 | 0.647081 | 0.562785 |
| 3 | product_cart | 782 | 244 | 0.300307 | 0.278539 | 0.464095 | 0.494929 |
| 2 | purchase | 833 | 249 | 0.319892 | 0.284247 | 1.065217 | 1.020492 |
По таблице уже видим, что показатели конверсии в группе А лучше, но построим для наглядности график.
fig = go.Figure()
fig.add_trace(go.Funnel(
name = 'Group A',
y = funnel['event_name'],
x = funnel['A'],
textinfo = 'value+percent initial+percent previous',
marker = {'color': 'tan'}
))
fig.add_trace(go.Funnel(
name = 'Group B',
y = funnel['event_name'],
x = funnel['B'],
textinfo = 'value+percent initial+percent previous',
marker = {'color': 'silver'}
))
fig.update_layout(
title='Конверсия пользователей по группам на каждом этапе воронки',
yaxis_title='событие',
title_x = 0.5)
fig.show()
Показатели конверсии в группе А значительно лучше на этапе просмотра страницы продукта и несколько лучше на этапе просмотра корзины и этапе покупки. Ожидаемый результат улучшения каждой из метрик в группе Б не менее, чем на 10% не достигнут.
После удаления пользователей без действий распределение по группам 75/25.
В группе А резкий и устойчивый рост количества регистраций и количества событий на вторую неделю эксперимента (с 14 декабря). Группа Б без резких изменений внутри периода.
У группы Б значительно лучше показатели по количеству событий в первую неделю эксперимента, которые нивелируются во вторую неделю.
Характер лайфтайма в обеих группах совпадает. Количество событий в первый день значительно превышает все остальные дни.
Пользователи группы Б немного чаще заходят с мобильных устройств, чем со стационарных.
Пользователи группы Б немного чаще логинятся, чем просматривают страницы продуктов или совершают покупки.
Суммы покупок в группах различаючтся незначительно. Чуть чаще (1%) пользователи группы Б совершают дешевые покупки (4.99), чем на большую сумму.
Среднее и медианное количество событий на пользователя в группе А лучше.
Показатели конверсии в группе А значительно лучше на этапе просмотра страницы продукта и несколько лучше на этапе просмотра корзины и этапе покупки. Ожидаемый результат улучшения каждой из метрик в группе Б не менее, чем на 10% не достигнут.
Проверим, находят ли статистические критерии разницу между группами А и Б на каждом этапе. Сформулируем гипотезы для каждого из этапов.
H_0: Конверсия в группе А и Б одинакова.
H_a: Конверсия в группе А и Б не одинакова.
Проведем проверку с помощью z-теста пропорций.
Примем пороговое значение alpha — 0,05.
Сравниваем между собой конверсию на трех этапах воронки: в просмотр страницы продукта, в просмотр корзины, в покупку. Несколько сравнений, проводимых на одних и тех же данных — это множественный тест, с каждой новой проверкой гипотезы растёт вероятность ошибки первого рода.
Поэтому с учетом множественности теста применим поправку Холма.
Проверим переменные с данными для проведения теста.
funnel
| group | event_name | A | B | A_conv | B_conv | A_step | B_step |
|---|---|---|---|---|---|---|---|
| 0 | login | 2604 | 876 | 1.000000 | 1.000000 | NaN | NaN |
| 1 | product_page | 1685 | 493 | 0.647081 | 0.562785 | 0.647081 | 0.562785 |
| 3 | product_cart | 782 | 244 | 0.300307 | 0.278539 | 0.464095 | 0.494929 |
| 2 | purchase | 833 | 249 | 0.319892 | 0.284247 | 1.065217 | 1.020492 |
Зададим функцию для проведения z-теста.
def z_test(event_A, event_B, total_A, total_B):
p1 = event_A / total_A
p2 = event_B / total_B
p_combined = (event_A + event_B) / (total_A + total_B)
diff = p1 - p2
z_value = diff / mth.sqrt(p_combined * (1 - p_combined) * (1/total_A + 1/total_B))
distr = stats.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
return p_value
Проведем сравнения для трех этапов воронки, считая размер группы от количества залогинившихся пользователей.
alpha = 0.05
quantity = 3
for i in [1, 2, 3]:
p_value = z_test(funnel['A'][i], funnel['B'][i], funnel['A'][0], funnel['B'][0])
print(f'Событие {funnel["event_name"][i]}: p-значение: {p_value}')
print('Уровень значимости:', alpha / quantity)
if p_value < alpha / quantity:
print('Отвергаем нулевую гипотезу')
else:
print('Не получилось отвергнуть нулевую гипотезу')
print('\n')
quantity -= 1
Событие product_page: p-значение: 8.195976000324734e-06 Уровень значимости: 0.016666666666666666 Отвергаем нулевую гипотезу Событие purchase: p-значение: 0.04864766695042433 Уровень значимости: 0.025 Не получилось отвергнуть нулевую гипотезу Событие product_cart: p-значение: 0.2215941567364419 Уровень значимости: 0.05 Не получилось отвергнуть нулевую гипотезу
В первом сравнении удалось отвергнуть нулевую гипотезу. Конверсия в просмотр страницы продукта в группах А и Б не одинакова.
Во втором и третьем сравнении нулевую гипотезу не отвергаем. Нет статистически значимой разницы между конверсиями групп А и Б в просмотр корзины и в покупку.
Для сравнения конверсий в группах А и Б проведен тест пропорций для каждого этапа воронки.
Принят уровень alpha 0,05 и с учетом множественности сравнений применена поправка Холма.
Конверсия в просмотр страницы продукта в группах А и Б не одинакова.
Конверсия в в просмотр корзины и в покупку в группах А и Б одинакова, нет статистически значимой разницы.
+ общее количество пользователей больше 6000,+ даты начала и окончания набора в тест соответствуют,+ аудитория — 15% пользователей из Европы,- окончание совершения событий за несколько дней до полного лайфтайма последней когорты,+/- распределение пользователей по группам не вполне равномерное,- по первоначальному отбору в тест попало 350 пользователей из других регионов — 5% датасета,- 45% пользователей в датасете не совершали никаких событий.Результат: ожидаемый результат улучшения каждой из метрик в группе Б не менее, чем на 10% не достигнут.
Рекомендации: